<?php
/**
 * YDatabaseLockBackend.php
 *
 * @author Hua Yang <htmlcook@gmail.com>
 * @since 2.0
 */

Yii::import('common.components.lock.LockBackendAbstract');

/**
 * Class YDatabaseLockBackend
 */
class YDatabaseLockBackend extends YLockBackendAbstract
{
	/**
	 * @var CDbConnection
	 */
	protected $db;

	public function init()
	{
		register_shutdown_function(array($this, 'releaseAll'));
		$this->db = Yii::app()->getDb();
	}

	/**
	 * @param string $name
	 * @param float $timeout
	 * @return bool
	 */
	public function acquire($name, $timeout = 30.0)
	{
		$timeout = max($timeout, 0.001);
		$expire = microtime(TRUE) + $timeout;
		if (isset($this->locks[$name])) {
			// Try to extend the expiration of a lock we already acquired.
			$success = (bool)$this->db->createCommand()->update('{{semaphore}}', array(
				'expire' => $expire,
			), 'name=:name AND value=:value', array(
				':name' => $name,
				':value' => $this->getLockId()
			));
			if (!$success) {
				// The lock was broken.
				unset($this->locks[$name]);
			}
			return $success;
		} else {
			// Optimistically try to acquire the lock, then retry once if it fails.
			// The first time through the loop cannot be a retry.
			$retry = FALSE;
			// We always want to do this code at least once.
			do {
				try {
					$this->db->createCommand()->insert('{{semaphore}}', array(
						'name' => $name,
						'value' => $this->getLockId(),
						'expire' => $expire,
					));
					// We track all acquired locks in the global variable.
					$this->locks[$name] = TRUE;
					// We never need to try again.
					$retry = FALSE;
				} catch (RuntimeException $e) {
					// Suppress the error. If this is our first pass through the loop,
					// then $retry is FALSE. In this case, the insert failed because some
					// other request acquired the lock but did not release it. We decide
					// whether to retry by checking lockMayBeAvailable(). This will clear
					// the offending row from the database table in case it has expired.
					$retry = $retry ? FALSE : $this->lockMayBeAvailable($name);
				}
				// We only retry in case the first attempt failed, but we then broke
				// an expired lock.
			} while ($retry);
		}
		return isset($this->locks[$name]);
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function lockMayBeAvailable($name)
	{
		$lock = $this->db->createCommand()
			->select('expire')
			->from('{{semaphore}}')
			->where('name=:name', array(':name' => $name))
			->queryRow();

		if (!$lock) {
			return TRUE;
		}
		$expire = (float)$lock['expire'];
		$now = microtime(TRUE);
		if ($now > $expire) {
			// We check two conditions to prevent a race condition where another
			// request acquired the lock and set a new expire time. We add a small
			// number to $expire to avoid errors with float to string conversion.
			return (bool)$this->db->createCommand()->delete('{{semaphore}}', 'name=:name AND value=:value AND expire<=:expire', array(
				':name' => $name,
				':value' => $lock['value'],
				':expire' => 0.0001 + $expire
			));
		}
		return FALSE;
	}

	/**
	 * @param string $name
	 */
	public function release($name)
	{
		unset($this->locks[$name]);
		$this->db->createCommand()->delete('{{semaphore}}', 'name=:name AND value=:value', array(
			':name' => $name,
			':value' => $this->getLockId()
		));
	}

	/**
	 * @param string $lockId
	 */
	public function releaseAll($lockId = null)
	{
		// Only attempt to release locks if any were acquired.
		if (!empty($this->locks)) {
			$this->locks = array();
			if (empty($lockId)) {
				$lockId = $this->getLockId();
			}
			$this->db->createCommand()->delete('{{semaphore}}', 'value=:value', array(
				':value' => $lockId
			));
		}
	}
}